12  Латентно-семантический анализ

12.1 Векторы

Векторные представления слов - это совокупность подходов к моделированию языка, которые позволяют осуществлять семантический анализ слов и составленных из них документов. Например, находить синонимы и квазисинонимы, а также анализировать значения слов в диахронной перспективе.

В математике вектор – это объект, у которого есть длина и направление, заданные координатами вектора. Мы можем изобразить вектор в двумерном или трехмерном пространстве, где таких координат две или три (по числу измерений), но это не значит, что не может быть 100- или даже 1000-мерного вектора: математически это вполне возможно. Обычно, когда говорят о векторах слов, имеют в виду именно многомерное пространство.

Что в таком случае соответствует измерениям и координатам? Тут есть несколько подходов.

Мы можем, например, создать матрицу термин-документ, где каждое слово “описывается” вектором его встречаемости в различных документах (разделах, параграфах…). Слова считаются похожими, если “похожи” их векторы (о том, как сравивать векторы, мы скажем чуть дальше). Аналогично можно сравнивать и сами документы.

As You Like It Twelfth Night Julius Caesar Henry V
battle 1 1 8 15
soldier 2 2 12 36
fool 37 58 1 5
clown 6 117 0 0

Второй подход - зафиксировать совместную встречаемость (или другую меру ассоциации) между словами. В таком случае мы строим матрицу термин-термин. За контекст в таком случае часто принимается произвольное контекстное окно, а не целый документ. Небольшое контекстное окно (на уровне реплики) скорее сохранит больше синтаксической информации. Более широкое окно позволяет скорее судить о семантике: в таком случае мы скорее заинтересованы в словах, которые имеют похожих соседей.

И матрица термин-документ, и матрица термин-термин на реальных данных будут длинными и сильно разреженными (sparse), т.е. большая часть значений в них будет равна 0. С точки зрения вычислений это не представляет большой трудности, но может служить источником “шума”, поэтому в обработке естественного языка вместо них часто используют так называемые плотные (dense) векторы. Для этого к исходной матрице применяются различные методы снижения размерности.

В этом уроке мы рассмотрим алгоритм LSA, который использует матрицу термин-документ и снижение размерности при помощи SVD, а в следующий раз поговорим о других подходах, в том числе с использованием (поверхностных) нейросетей.

12.2 Латентно-семантический анализ

LSA (Latent Semantic Analysis), или LSI (Latent Semantic Indexing) – это метод семантического анализа текста, который позволяет сопоставить слова и документы с некоторыми темами (топиками). Слово “latent” (англ. “скрытый”) в названии указывает на то, сами темы заранее не известны, и задача алгоритма как раз и заключается в том, чтобы их выявить.

Создатели метода LSA опираются на основополагающий принцип дистрибутивной семантики, согласно которому смысл слова определяется его контекстами, а смысл предложений и целых документов представляет собой своего рода сумму (или среднее) отдельных слов.

На входе алгоритм LSA требует матрицу термин-документ. Она может хранить сведения о встречаемости слов в документах, хотя нередко используется уже рассмотренная мера tf-idf. Это связано с тем, что не все слова (даже после удаления стоп-слов) служат хорошими показателями темы: слово “дорожное”, например, служит лучшим показателем темы, чем слово “происшествие”, которое можно встретить и в других контекстах. Tf-idf понижает веса для слов, которые присутствуют во многих документах коллекции.

Дальше мы рассмотрим общий принцип действия алгоритма на очень простом примере, после чего попытаемся его применить к реальным данным.

12.3 LSA на простом примере

Дан “корпус” из пяти документов.

doc text
d1 Romeo and Juliet.
d2 Juliet: O happy dagger!
d3 Romeo died by dagger.
d4 “Live free or die”, that’s the New-Hampshire’s motto.
d5 Did you know, New Hampshire is in New-England.

После удаления стоп-слов термдокументная матрица выглядит так.

По этой матрице пока нельзя сделать вывод о том, с какими темами связаны, с одной стороны, слова, а с другой - документы. Ее необходимо “переупорядочить” так, чтобы сгруппировать слова и документы по темам и избавиться от малоинформативных тем. Примерно так.

Для этого используется алгебраическая процедура под названием сингулярное разложение матрицы (SVD).

12.4 Сингулярное разложение матрицы

При сингулярном разложении исходная матрица \(A_r\) проецируется в пространство меньшей размерности, так что получается новая матрица \(A_k\), которая представляет собой малоранговую аппроксимацию исходной матрицы.

Для получения новой матрицы применяется следующая процедура. Сначала для матрицы \(A_r\) строится ее сингулярное разложение (Singular Value Decomposition) по формуле: \(A = UΣV^t\) . Иными словами, одна матрица представляется в виде произведения трех других, из которых средняя - диагональная.

Источник: Яндекс Практикум

Здесь U — матрица левых сингулярных векторов матрицы A; Σ — диагональная матрица сингулярных чисел матрицы A; V — матрица правых сингулярных векторов матрицы A. Мы пока не будем пытаться понять, что такое сингулярные векторы с математической точки зрения; достаточно думать о них как о топиках-измерениях, которые задают пространство для наших документов.

Строки матрицы U соответствуют словам, при этом каждая строка состоит из элементов разных сингулярных векторов (на иллюстрации они показаны разными оттенками). Аналогично в V^t столбцы соответствуют отдельным документам. Следовательно, кажда строка матрицы U показывает, как связаны слова с топиками, а столбцы V^T – как связаны топики и документы.

Некоторые векторы соответствуют небольшим сингулярным значениям (они хранятся в диагональной матрице) и потому хранят мало информации, поэтому на следующем этапе их отсекают. Для этого наименьшие значения в диагональной матрице заменяются нулями. Такое SVD называется усеченным. Сколько топиков оставить при усечении, решает человек.

Собственно эмбеддингами, или векторными представлениями слова, называют произведения каждой из строк матрицы U на Σ, а эмбеддингами документа – произведение столбцов V^t на Σ. Таким образом мы как бы “вкладываем” (англ. embed) слова и документы в единое семантическое пространство, число измерений которого будет равно числу сингулярных векторов.

12.5 SVD в базовом R

Применим SVD к игрушечной термдокументной матрице, которую мы создали выше. В R для этого есть специальная функция (и не одна).

my_svd = svd(df)

my_svd
$d
[1] 2.2852979 2.0102582 1.3606993 1.1181404 0.7965768

$u
           [,1]       [,2]        [,3]        [,4]        [,5]
[1,] -0.3961528  0.2800574  0.57117132  0.44968498  0.10183880
[2,] -0.3142681  0.4495321 -0.41059055  0.51301824 -0.20390607
[3,] -0.1782395  0.2689915 -0.49732052 -0.25699778 -0.04305233
[4,] -0.4383638  0.3685083 -0.01287918 -0.57732882  0.21964021
[5,] -0.2638806 -0.3459214 -0.14578908  0.04748488 -0.41748402
[6,] -0.5240048 -0.2464047  0.33865227 -0.27284616 -0.15479149
[7,] -0.2638806 -0.3459214 -0.14578908  0.04748488 -0.41748402
[8,] -0.3263732 -0.4596688 -0.31700297  0.23724380  0.72485145

$v
           [,1]       [,2]       [,3]        [,4]        [,5]
[1,] -0.3108657  0.3629332  0.1180134  0.86098600 -0.12813236
[2,] -0.4073304  0.5407425 -0.6767037 -0.28735960 -0.03429449
[3,] -0.5944614  0.2000544  0.6591790 -0.35817507  0.20925479
[4,] -0.6030458 -0.6953914 -0.1983751  0.05309476 -0.33255810
[5,] -0.1428143 -0.2286616 -0.2329706  0.21217712  0.90995798

Сингулярные значения меньше двух отсекаем, остается два значения. Это позволит нам визуализировать результат; в реальном исследовании используется больше измерений (от 50 до 1000 в зависимости от корпуса).

my_svd$d[3:5] <- 0

s <- diag(my_svd$d) 

s
         [,1]     [,2] [,3] [,4] [,5]
[1,] 2.285298 0.000000    0    0    0
[2,] 0.000000 2.010258    0    0    0
[3,] 0.000000 0.000000    0    0    0
[4,] 0.000000 0.000000    0    0    0
[5,] 0.000000 0.000000    0    0    0

Матрицу правых сингулярных векторов транспонируем.

vt <- t(my_svd$v)

vt
           [,1]        [,2]       [,3]        [,4]       [,5]
[1,] -0.3108657 -0.40733041 -0.5944614 -0.60304575 -0.1428143
[2,]  0.3629332  0.54074246  0.2000544 -0.69539140 -0.2286616
[3,]  0.1180134 -0.67670369  0.6591790 -0.19837510 -0.2329706
[4,]  0.8609860 -0.28735960 -0.3581751  0.05309476  0.2121771
[5,] -0.1281324 -0.03429449  0.2092548 -0.33255810  0.9099580

Теперь перемножим матрицы, чтобы получить эмбеддинги.

# эмбеддинги слов
u <- my_svd$u
word_emb <- u %*% s |> 
  round(3)

rownames(word_emb) <- rownames(df)

word_emb
                [,1]   [,2] [,3] [,4] [,5]
romeo         -0.905  0.563    0    0    0
juliet        -0.718  0.904    0    0    0
happy         -0.407  0.541    0    0    0
dagger        -1.002  0.741    0    0    0
live          -0.603 -0.695    0    0    0
die           -1.198 -0.495    0    0    0
free          -0.603 -0.695    0    0    0
new-hampshire -0.746 -0.924    0    0    0
# эмбеддинги документов
doc_emb <- s %*% vt |> 
  round(3)

colnames(doc_emb) <- colnames(df)

doc_emb 
        d1     d2     d3     d4     d5
[1,] -0.71 -0.931 -1.359 -1.378 -0.326
[2,]  0.73  1.087  0.402 -1.398 -0.460
[3,]  0.00  0.000  0.000  0.000  0.000
[4,]  0.00  0.000  0.000  0.000  0.000
[5,]  0.00  0.000  0.000  0.000  0.000

Добавим условный поисковый запрос: dies, dagger. Очевидно, ближе всего к документы d3, т.к. он содержит оба слова. Но какой документ должен быть следующим? И d2, d4 содержат по одному слову из запроса, а явно релевантный d1 – ни одного. Координаты поискового запроса (который рассматриваем как псевдодокумент) считаем как среднее арифметическое координат:

q = c("die", "dagger")
q_doc <-  colSums(word_emb[rownames(word_emb) %in% q, ]) / 2
q_doc
[1] -1.100  0.123  0.000  0.000  0.000

Объединив все в единый датафрейм, можем визуализировать.

library(tidyverse)

plot_tbl <- rbind(word_emb, t(doc_emb), q_doc) |> 
  as.data.frame() |> 
  rownames_to_column("item") |> 
  rename(dim1 = V1, dim2 = V2) |> 
  mutate(type = c(rep("word", 8), rep("doc", 6))) |> 
  select(!starts_with("V"))

plot_tbl

Итак, “поисковый запрос” оказался ближе к d2, чем к d4, хотя в каждом из документов было одно слово из запроса. Более того: он оказался ближе к d1, в котором не было ни одного слова из запроса! Наш алгоритм оказался достаточно умен, чтобы понять, что d1 более релевантен, хотя и не содержит точных совпадений с поисковыми словами. Возможно, человек дал бы такую же рекомендацию.

12.6 Межвекторное расстояние

Мы исследовали наш небольшой корпус визуально, но там, где число измерений больше двух, это просто невозможно. На практике расстояние или сходство между векторами слов (или документов) вычисляется алгебраически. Наиболее известны манхэттенское и евклидово расстояние, а также косинусное сходство. Для анализа текстовых данных как правило применяется косинусное сходство.

Все их можно посчитать в R для заданной пары векторов.

doc_mx <- plot_tbl |> 
  filter(row_number() > 8 ) |> 
  column_to_rownames("item") |> 
  select(dim1, dim2) |> 
  as.matrix()

doc_mx
        dim1   dim2
d1    -0.710  0.730
d2    -0.931  1.087
d3    -1.359  0.402
d4    -1.378 -1.398
d5    -0.326 -0.460
q_doc -1.100  0.123
dist_mx <- doc_mx |> 
  philentropy::distance(method = "cosine", use.row.names = T) 

dist_mx
               d1         d2        d3          d4         d5     q_doc
d1     1.00000000  0.9979996 0.8719223 -0.02109092 -0.1817325 0.7725616
d2     0.99799957  1.0000000 0.8392224 -0.08425530 -0.2435368 0.7308749
d3     0.87192229  0.8392224 1.0000000  0.47114573  0.3230341 0.9845083
d4    -0.02109092 -0.0842553 0.4711457  1.00000000  0.9869622 0.6185045
d5    -0.18173249 -0.2435368 0.3230341  0.98696218  1.0000000 0.4839672
q_doc  0.77256165  0.7308749 0.9845083  0.61850449  0.4839672 1.0000000

Чтобы получить расстояние (а не сходство), вычитаем результат из единицы.

round(1 - dist_mx, 3)
         d1    d2    d3    d4    d5 q_doc
d1    0.000 0.002 0.128 1.021 1.182 0.227
d2    0.002 0.000 0.161 1.084 1.244 0.269
d3    0.128 0.161 0.000 0.529 0.677 0.015
d4    1.021 1.084 0.529 0.000 0.013 0.381
d5    1.182 1.244 0.677 0.013 0.000 0.516
q_doc 0.227 0.269 0.015 0.381 0.516 0.000

Аналогично вычисляются расстояния между словами. При желании все косинусы можно пересчитать в градусы, чтобы узнать точный угол между векторами.

acos(dist_mx[3,1])  # acos для d3 и d1 (cos = 0.872)
[1] 0.5116817
180 * acos(dist_mx[3,1]) / pi # переводим из радиан в градусы
[1] 29.3172

12.7 Подгтовка данных

Теперь, когда мы поняли общий принцип работы алгоритма LSA, оценим его возможности на датасете с подборкой новостей на русском языке. Файл в формате .Rdata можно скачать в формате .Rdata по ссылке.

load("../data/news.Rdata")

str(news_2019)
tibble [3,407 × 2] (S3: tbl_df/tbl/data.frame)
 $ text : chr [1:3407] "Россиянам дали советы при выборе чая. Рекомендации в честь Международного дня чая, который отмечается 15 декабр"| __truncated__ "Спикер Госдумы Вячеслав Володин назвал угрозой суверенитету России заявление председателя Коммунистической парт"| __truncated__ "Украинская ЛГБТ-активистка Виктория Гуйвик обвинила известного ню-фотографа Марата Сафина в изнасиловании. Пост"| __truncated__ "В Москве полицейские застрелили мужчину при попытке его задержать. Об этом «Ленте.ру» сообщили в пресс-службе м"| __truncated__ ...
 $ topic: chr [1:3407] "Россия" "Россия" "Культура" "Силовые структуры" ...

Исходный датасет содержит почти полмиллиона новостей на русском языке с 2019 по 2023 г.; для ускорения вычислений мы взяли данные только за один год. Добавим id для документов.

news_2019 <- news_2019 |> 
  mutate(id = paste0("doc", row_number()))

Основные темы статей выглядят так:

news_2019 |> 
  group_by(topic) |> 
  summarise(n = n()) |> 
  arrange(-n) 

Составим список стоп-слов.

library(stopwords)
stopwords_ru <- c(
  stopwords("ru", source = "snowball"),
  stopwords("ru", source = "marimo"),
  stopwords("ru", source = "nltk"), 
  stopwords("ru", source  = "stopwords-iso")
  )

stopwords_ru <- sort(unique(stopwords_ru))
length(stopwords_ru)
[1] 715

Разделим статьи на слова и удалим стоп-слова; это может занять несколько минут.

library(tidytext)
news_tokens <- news_2019 |> 
  unnest_tokens(token, text) |> 
  filter(!token %in% stopwords_ru)

Даже после удаления стоп-слов в нашем датасете осталось примерно 10 млн токенов; однако значительная их часть встречается лишь несколько раз и для тематического моделирования бесполезна. Поэтому можно от них избавиться.

news_tokens_pruned <- news_tokens |> 
  add_count(token) |> 
  filter(n > 10) |> 
  select(-n)

Также избавимся от цифр, хотя стоит иметь в виду, что их пристутствие в тексте может быть индикатором темы: в некоторых случах лучше не удалять цифры, а, например, заменять их на некую последовательность символов вроде digit и т.п. Токены на латинице тоже удаляем.

news_tokens_pruned <- news_tokens_pruned |> 
  filter(str_detect(token, "[\u0400-\u04FF]")) |> 
  filter(!str_detect(token, "\\d"))
Warning in rm(news_tokens): object 'news_tokens' not found
news_tokens_pruned

Посмотрим на статистику по словам.

news_tokens_pruned |> 
  group_by(token) |> 
  summarise(n = n()) |> 
  arrange(-n)

Этап подготовки данных – самый трудоемкий и не самый творческий, но не стоит им пренебрегать, потому что от этой работы напрямую зависит качество модели.

12.8 TF-IDF: опрятный подход

Вместо показателей абсолютной встречаемости при анализе больших текстовых данных применяется tf-idf. Эта статистическая мера не используется, если дана матрица термин-термин, но она хорошо работает с матрицами термин-документ, позволяя повысить веса для тех слов, которые служат хорошими дискриминаторами. Например, “заявил” и “отметил”, хотя это не стоп-слова, могут встречаться в разных темах.

news_counts <- news_tokens_pruned |>
  count(token, id)

news_counts
news_counts |> 
  arrange(id)

Добавляем tf_idf.

news_tf_idf <- news_counts |> 
  bind_tf_idf(token, id, n) |> 
  arrange(tf_idf) |> 
  select(-n, -tf, -idf)

news_tf_idf

12.9 DocumentTermMatrix

Посмотрим на размер получившейся таблицы.

object.size(news_tf_idf)
5369712 bytes
format(object.size(news_tf_idf), units = "auto")
[1] "5.1 Mb"

Чтобы вычислить SVD, такую таблицу необходимо преобразовать в матрицу термин-документ. Оценим ее размер:

# число уникальных токенов
m <- unique(news_tf_idf$token) |> 
  length()
m
[1] 6299
# число уникальных документов
n <- unique(news_tf_idf$id) |> 
  length()  
n
[1] 3407
# число элементов в матрице 
m * n
[1] 21460693

Поэтому мы используем специальный формат для хранения разреженных матриц.

dtm <- news_tf_idf |> 
  cast_sparse(token, id, tf_idf)
# первые 10 рядов и 5 столбцов
dtm[1:10, 1:5]
10 x 5 sparse Matrix of class "dgCMatrix"
               doc608     doc1670     doc2170     doc2184     doc2219
ранее     0.003530193 .           .           0.005002585 .          
россии    0.010127611 0.003675658 0.004689633 0.004783897 0.005471238
словам    .           .           0.011776384 .           0.006869557
рублей    0.006686328 .           0.027865190 .           .          
рассказал 0.007250151 .           .           0.010274083 .          
данным    0.007406320 .           .           .           0.012003345
издание   0.007759457 .           .           .           0.012575671
ходе      0.008675929 .           .           .           .          
заявил    0.016729327 .           .           .           .          
частности 0.008860985 .           0.012309349 .           .          

Снова уточним размер матрицы.

format(object.size(dtm), units = "auto")
[1] "3 Mb"

12.10 SVD с пакетом irlba

Метод для эффективного вычисления усеченного SVD на больших матрицах реализован в пакете irlba. Возможно, придется подождать ⏳.

library(irlba)
lsa_space<- irlba::svdr(dtm, 20)

Функция вернет список из трех элементов:

  1. d: k аппроксимированных сингулярных значений;
  2. u: k аппроксимированных левых сингулярных векторов;
  3. v: k аппроксимированных правых сингулярных векторов.

Полученную LSA-модель можно использовать для поиска наиболее близких слов и документов или для изучения тематики корпуса – в последнем случае нас может интересовать, какие топики доминируют в тех или иных документах и какими словами они в первую очередь представлены.

12.11 Эмбеддинги слов

Вернем имена рядов матрице левых сингулярных векторов и добавим имена столбцов.

rownames(lsa_space$u) <- rownames(dtm)
colnames(lsa_space$u) <- paste0("dim", 1:20)

Теперь посмотрим на эмбеддинги слов.

word_emb <- lsa_space$u |> 
  as.data.frame() |> 
  rownames_to_column("word") |> 
  as_tibble()

word_emb

Преобразуем наши данные в длинный формат.

word_emb_long <- word_emb |> 
  pivot_longer(-word, names_to = "dimension", values_to = "value") |>
  mutate(dimension = as.numeric(str_remove(dimension, "dim")))
  

word_emb_long

12.12 Визуализация топиков

Визуализируем несколько топиков, чтобы понять, насколько они осмыслены.

word_emb_long |> 
  filter(dimension < 10) |> 
  group_by(dimension) |> 
  top_n(10, abs(value)) |> 
  ungroup() |> 
  mutate(word = reorder_within(word, value, dimension)) |> 
  ggplot(aes(word, value, fill = dimension)) +
  geom_col(alpha = 0.8, show.legend = FALSE) +
  facet_wrap(~dimension, scales = "free_y", ncol = 3) +
  scale_x_reordered() +
  coord_flip() +
  labs(
    x = NULL, 
    y = "Value",
    title = "Первые 9 главных компонент за 2019 г.",
    subtitle = "Топ-10 слов"
  ) +
  scale_fill_viridis_c()

12.13 Ближайшие соседи

Эмбеддинги можно использовать для поиска ближайших соседей.

library(widyr)
nearest_neighbors <- function(df, feat, doc=F) {
  inner_f <- function() {
    widely(
        ~ {
          y <- .[rep(feat, nrow(.)), ]
          res <- rowSums(. * y) / 
            (sqrt(rowSums(. ^ 2)) * sqrt(sum(.[feat, ] ^ 2)))
          
          matrix(res, ncol = 1, dimnames = list(x = names(res)))
        },
        sort = TRUE
    )}
  if (doc) {
    df |> inner_f()(doc, dimension, value) }
  else {
    df |> inner_f()(word, dimension, value)
  }
}
nearest_neighbors(word_emb_long, "сборная")
nearest_neighbors(word_emb_long, "завод")

12.14 Похожие документы

Информация о документах хранится в матрице правых сингулярных векторов.

rownames(lsa_space$v) <- colnames(dtm)
colnames(lsa_space$v) <- paste0("dim", 1:20)

Посмотрим на эмбеддинги документов.

doc_emb <- lsa_space$v |> 
  as.data.frame() |> 
  rownames_to_column("doc") |> 
  as_tibble()

doc_emb

Преобразуем в длинный формат.

doc_emb_long <- doc_emb |> 
  pivot_longer(-doc, names_to = "dimension", values_to = "value") |>
  mutate(dimension = as.numeric(str_remove(dimension, "dim")))
  

doc_emb_long

И найдем соседей для произвольного документа.

nearest_neighbors(doc_emb_long, "doc14", doc = TRUE)

Выведем документ 14 вместе с его соседями 1893 и 2043.

news_2019 |> 
  filter(id %in% c("doc14", "doc1893", "doc2043")) |> 
  mutate(text = str_trunc(text, 70)) 

Поздравляем, вы построили свою первую рекомендательную систему 🍺.